Skip to content

fix: record STEP_SUSPENDED for inline step_hook suspensions (stranded resume)#550

Merged
yasha-dev1 merged 3 commits into
QualityUnit:mainfrom
LivinTribunal:fix/step-hook-inline-suspension
Jul 10, 2026
Merged

fix: record STEP_SUSPENDED for inline step_hook suspensions (stranded resume)#550
yasha-dev1 merged 3 commits into
QualityUnit:mainfrom
LivinTribunal:fix/step-hook-inline-suspension

Conversation

@LivinTribunal

Copy link
Copy Markdown
Contributor

Problem

A step that suspends via step_hook() on the inline execution path (a force_local=True step, or the local runtime) never gets a STEP_SUSPENDED event — only the Celery step worker records one (celery/tasks.py:_record_step_suspended). On resume, replay sees the step's STEP_STARTED with no STEP_SUSPENDED, treats it as still in progress, and re-suspends — forever. The run is stranded.

Empirically confirmed downstream (FlowHunt hook-as-tool, QualityUnit/urlslab-app#6649), which currently ships an app-side workaround that pre-records the event before every step_hook call. This PR makes that workaround unnecessary.

Repro on main (new regression test, red before this change):

AssertionError: STEP_SUSPENDED must be recorded on the inline path — assert 0 == 1
after start status: SUSPENDED → after resume_hook status: SUSPENDED → STRANDED

Changes

1. Record STEP_SUSPENDED for inline step_hook suspensions. New single deduped writer engine/executor.record_step_suspended_if_needed (guards with has_event), called from the live inline sites: both runtime/local.py handlers and the 4 Celery orchestration handlers (start/resume/recover/child) for force_local steps. The existing Celery step-worker recorder is kept, now deduped through the same guard.

  • The SuspensionSignal carried the composite run_id:step_id checkpoint key and no step_name, while replay matches on the bare deterministic step id — an event written from the signal as-is would never clear the in-progress state. The deterministic step_id/step_name are now plumbed through the step execution context (step_checkpoint.py contextvars) and attached to both signal raises, with a prefix-strip fallback.
  • Note: the brief target engine/executor._execute_workflow_local turned out to be dead code (zero callers); it is wired up anyway for consistency, but the fix lives at the live sites.

2. Fast-answer race guard for step_hook:. The existing guards in celery/tasks.py matched reason.startswith("hook:") only; extended to step_hook:. The local runtime had no guard at all — and its resume_hookschedule_resumeresume_workflow chain is synchronous, so an answer delivered from within on_created triggered a nested resume that couldn't progress. Added _hook_already_answered + re-drive in both runtime/local.py handlers.

3. Public step-hook counter API. ctx.get_step_hook_counter() / ctx.set_step_hook_counter(value) on the workflow context, so apps that checkpoint a step and re-execute it in a fresh process can restore the counter and reproduce the same deterministic hook id — previously only possible by poking the private _step_hook_counter.

Folded-in fix: InMemoryStorageBackend.get_events compared the EventType enum against string values (e.type in event_types) — never equal, so type-filtered queries returned nothing and has_event was always False on the in-memory backend. Now compares e.type.value, matching the file/sqlite/postgres backends. The dedup and race guards above depend on it.

Tests

4 new regression tests in tests/unit/test_step_hook.py (local-runtime suspend→resume completion, crash-replay re-execution without duplicate HOOK_CREATED, fast answer from on_created, counter round-trip + hook-id reproduction). Test 1 verified red on unpatched main first.

Full suite: 889 passed, 21 skipped (skips are pre-existing infra-gated: Redis/optional drivers). ruff check + ruff format --check clean; mypy clean on changed modules.

Caveat: the Celery-path changes are covered by the existing Celery unit tests (green) — the end-to-end dispatched/force_local integration tests need live Redis/Celery and were skipped in this environment.

Version left at 0.3.7 per RELEASING.md (maintainer-driven bumps).

LivinTribunal and others added 3 commits July 8, 2026 11:26
When a step suspended via step_hook() on the inline path (a force_local
step, or the local runtime) no STEP_SUSPENDED event was recorded — only
the Celery step worker recorded one. On resume, replay saw the step's
STEP_STARTED without a STEP_SUSPENDED, treated the step as still in
progress, and re-suspended forever, stranding the run.

- Record STEP_SUSPENDED in the engine immediately after WORKFLOW_SUSPENDED
  on every inline path via a single deduplicated writer
  (record_step_suspended_if_needed). Correct ordering by construction, no
  polling. Wired into the local runtime (start/resume) and the Celery
  orchestration handlers (start/resume/recover/child) for force_local
  steps. The engine's _execute_workflow_local records it too.
- step_hook() now attaches the deterministic step id and step name (not
  the composite run_id:step_id checkpoint key) to its SuspensionSignal, so
  the recorded STEP_SUSPENDED matches STEP_STARTED and replay clears the
  in-progress state. Plumbed through set_step_execution_context.
- Keep the Celery _record_step_suspended recorder for dispatched steps but
  short-circuit it with a has_event dedup so it never double-records.
- Extend the fast-answer race guard to cover step_hook: suspensions (a hook
  answered from within on_created, before the suspension bookkeeping
  exists) on both runtimes, so the answer is never lost.
- Add public WorkflowContext.get_step_hook_counter/set_step_hook_counter so
  apps can restore the deterministic hook-id counter when re-executing a
  checkpointed step across a process boundary.

Folded-in fix: InMemoryStorageBackend.get_events compared the EventType
enum against a list of string type values, so type-filtered queries (and
thus has_event) silently matched nothing. Compare on .value. The dedup and
fast-answer guards above depend on has_event working on the in-memory
harness.

Adds regression tests: local-runtime completion after resume_hook,
crash-replay re-execution without duplicate HOOK_CREATED, fast-answer from
on_created, and the counter API round-trip / hook-id reproduction.
The STEP_SUSPENDED dedup skipped recording once ANY STEP_SUSPENDED existed
for a step_id. But a step can suspend multiple times on the same
deterministic step_id — sequential step_hook() rounds within one step, and
crash-replay re-suspension. From the second round on, a fresh STEP_STARTED
was recorded with no matching STEP_SUSPENDED, so replay kept the step in
_steps_in_progress, the wrapper re-took the is_step_in_progress branch, and
the run re-suspended forever (stranded in SUSPENDED). The same over-dedup
regressed the Celery step-worker path, which previously recorded
un-deduped per suspension.

Replace the "a STEP_SUSPENDED ever exists" check with
step_suspended_already_recorded(): a STEP_SUSPENDED counts only if it
appears after the most recent STEP_STARTED for the step_id (one
type-filtered, sequence-ordered get_events scan). Both the inline writer
(record_step_suspended_if_needed) and the Celery recorder
(_record_step_suspended) now share this predicate, so both paths record one
event per suspension round and still dedup within a round.

Adds regression tests: two sequential step_hook rounds in one step complete
on the local runtime (asserting one STEP_SUSPENDED per suspended
STEP_STARTED cycle), and a crash-replay variant that replays with a fresh
executor across the second suspension and completes after the second answer.
Cover the stranded-resume fix through a real FileStorageBackend, resuming
across freshly constructed backend instances (config reset between phases)
to reproduce the cross-process replay that the in-memory unit tests do not
exercise: suspend->persist->resume completes, crash-replay without duplicate
hook, two sequential hooks on one step_id, fast-answer race, and repeated
answer-less replay staying cleanly resumable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yasha-dev1 yasha-dev1 merged commit 99a6d5b into QualityUnit:main Jul 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants